# -*- coding: utf-8 -*-

# Podboy -- A podcast aggregator/player
#
# Copyright (C) 2009-2012 Valéry Febvre <vfebvre@easter-eggs.com>
# http://code.google.com/p/podboy/
#
# This file is part of Podboy.
#
# Podboy is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Podboy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import os, time
import re, rfc822

import util
from database import db

def fetch_url(url, info_only = False):
    result = {'data': None, 'info': None, 'status': None}

    import urlparse
    protocol = urlparse.urlparse(url)[0]
    if not protocol == 'http':
        print 'Error: bad url %s' % url
        result['status'] = -1
        return result

    try:
        import urllib2
        s = urllib2.urlopen(url)
        result['status'] = 200
        result['info']   = s.info()
        if not info_only:
            result['data'] = s.read()
        s.close()
    except IOError, e:
        if hasattr(e, 'reason'):
            print "Error: failed to download file %s (reason: %s)" % (url, e.reason)
        elif hasattr(e, 'code'):
            print "Error: failed to download file %s (error code: %s)" % (url, e.code)
        result['status'] = -2
    finally:
        return result

def normalize_status(status):
    # Code snippet from gPodder
    # Based on Mark Pilgrim's "Atom aggregator behaviour" article
    # http://diveintomark.org/archives/2003/07/21/atom_aggregator_behavior_http_level
    if status in (200, 301, 302, 304, 400, 401, 403, 404, 410, 500):
        return status
    elif status >= 200 and status < 300:
        return 200
    elif status >= 300 and status < 400:
        return 302
    elif status >= 400 and status < 500:
        return 400
    elif status >= 500 and status < 600:
        return 500
    else:
        return status


class Podcast(object):
    def __init__(self, url = None):
        self.id           = None
        self.link         = None
        self.title        = None
        self.description  = None
        self.cover_link   = None
        self.website_link = None
        self.etag         = None
        self.modified     = None
        self.updated      = None

        self.path  = None
        self.error = None

        if not url:
            return
        url = util.normalize_feed_url(url)
        if not url:
            self.error = (0, 'Invalid URL')
            return
        if Podcast.load(link = url):
            self.error = (1, 'You are already subscribed to the podcast')
            return

        data = self._get_data(url)
        if data:
            self._load_from_dict(data)

            self.__episodes = {}
            for entry in self.d.entries:
                episode = PodcastEpisode(self, entry)
                if not episode.error:
                    self.__episodes[episode.guid] = episode

            self.save()
            self._get_cover()

    @classmethod
    def load(cls, id = None, link = None, data = None):
        if id:
            sql = """
                SELECT * FROM podcasts WHERE id = ?
            """
            data = db.cnx.cursor().execute(sql, (id,)).fetchone()
        elif link:
            sql = """
                SELECT * FROM podcasts WHERE link = ?
            """
            data = db.cnx.cursor().execute(sql, (link,)).fetchone()
        if data is None:
            return None

        p = cls()
        p._load_from_dict(data)
        return p

    def _load_from_dict(self, d):
        for key in d:
            if hasattr(self, key):
                setattr(self, key, d[key])

    def _get_data(self, url, etag = None, modified = None):
        import feedparser
        if etag:
            self.d = feedparser.parse(url, etag = etag)
        elif modified:
            self.d = feedparser.parse(url, modified = time.gmtime(modified))
        else:
            self.d = feedparser.parse(url)

        # check parsing result
        if self.d is None:
            self.error = (2, 'Feed parsing failed')
            return None
        if not hasattr(self.d, 'status'):
            self.error = (2, 'Feed has no status code')
            return None
        if not self.d.version and self.d.status != 304 and self.d.status != 401:
            self.error = (2, 'Feed type is unknown')
            return None
        self.d.status = normalize_status(self.d.status)
        if self.d.status == 301:
            # new location
            pass
        elif self.d.status == 304:
            # No change since last check
            return None
        elif self.d.status >= 400:
            if self.d.status == 400:
                self.error = (2, 'Feed parsing failed (400 bad request)')
                return None
            elif self.d.status == 401:
                self.error = (2, 'Feed parsing failed (401 auth required)')
                return None
            elif self.d.status == 403:
                self.error = (2, 'Feed parsing failed (403 forbidden)')
                return None
            elif self.d.status == 404:
                self.error = (2, 'Feed parsing failed (404 not found)')
                return None
            elif self.d.status == 410:
                self.error = (2, 'Feed parsing failed (410 resource is gone, MUST unsubscribe)')
                return None
            elif self.d.status == 500:
                self.error = (2, 'Feed parsing failed (500 internal server error)')
                return None
            else:
                self.error = (2, 'Feed parsing failed (unknown status code)')
                return None

        data = {}
        data['link']         = url
        data['title']        = self.d.feed.get('title', data['link'])
        data['path']         = data['title']
        # filter FAT32 illegal characters
        for c in ('/', '\\', ':', ';', '*', '?', '"', '<',  '>', '|'):
            data['path'] = data['path'].replace(c, '')
        data['description']  = self.d.feed.get('subtitle', None)
        data['cover_link']   = self.d.feed.image.get('href', None) if hasattr(self.d.feed, 'image') else None
        data['website_link'] = self.d.feed.get('link', None)
        data['etag']         = self.d.get('etag', None)
        if hasattr(self.d, 'modified') and self.d.modified is not None:
            data['modified'] = rfc822.mktime_tz(self.d.modified + (0,))
        if hasattr(self.d, 'updated') and self.d.updated is not None:
            data['updated'] = rfc822.mktime_tz(self.d.updated + (0,))
        else:
            data['updated'] = time.time()

        return data

    def _get_cover(self):
        if self.cover_full_path and not os.path.exists(self.cover_full_path):
            print 'download cover for feed "%s"' % self.title.encode('utf-8')
            result = fetch_url(self.cover_link)
            if result['status'] == 200:
                f = open(self.cover_full_path, 'w+')
                f.write(result['data'])
                f.close()

    @property
    def full_path(self):
        media_dir = db.get_setting('media_dir')
        if media_dir and os.path.exists(media_dir.encode('utf-8')):
            path = os.path.join(media_dir, self.path).encode('utf-8')
            if not os.path.exists(path):
                os.makedirs(path)
            return path
        return None

    @property
    def cover_full_path(self):
        if self.full_path and self.cover_link:
            return os.path.join(self.full_path, ''.join(util.filename_from_url(self.cover_link)).encode('utf-8'))
        else:
            return None

    def check_updates(self):
        ue = Podcast()
        data = ue._get_data(self.link, etag = self.etag, modified = self.modified)

        if data is None:
            self.error = ue.error
            return None
        else:
            counter = [0, 0]
            ue._load_from_dict(data)
            # add new episodes
            new_guids = []
            for entry in ue.d.entries:
                episode = PodcastEpisode(self, entry)
                if not episode.error:
                    new_guids.append(episode.guid)
                    if PodcastEpisode.load(guid = episode.guid) is None:
                        print 'ADD new episode:', episode.title.encode('utf-8')
                        episode.save(self.id)
                        counter[0] += 1
            # remove old episodes
            # FIXME: an episode removed from feed can have its media still available
            # must we remove it in this case ?
            for row in db.get_episodes(podcast_id = self.id, downloaded = 0):
                if row['guid'] not in new_guids:
                    episode = PodcastEpisode.load(row['id'])
                    episode.delete()
                    print 'DEL old episode:', row['title'].encode('utf-8'), '(removed from feed)'
                    counter[1] += 1
            # update podcast info
            sql = """
                UPDATE podcasts
                SET cover_link = ?, etag = ?, modified = ?, updated = ?
                WHERE id = ?
            """
            db.cnx.cursor().execute(sql, (ue.cover_link, ue.etag, ue.modified, ue.updated, self.id))
            db.cnx.commit()
            return counter

    def save(self):
        cursor = db.cnx.cursor()

        sql = """
            INSERT INTO podcasts
            (link, title, description, cover_link, website_link, etag, modified, updated, path)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
        """
        values = (
            self.link,
            self.title,
            self.description,
            self.cover_link,
            self.website_link,
            self.etag,
            self.modified,
            self.updated,
            self.path,
            )
        cursor.execute(sql, values)
        db.cnx.commit()

        self.id = cursor.lastrowid

        # save episodes
        for guid, episode in self.__episodes.iteritems():
            episode.save(self.id)

    def update(self, title, link):
        fields = []
        values = []
        errors = []

        if not title:
            errors.append('Title: It can be empty')
        elif title != self.title:
            fields.append('title = ?')
            values.append(title)

        if not link:
            errors.append('Source URL: It can be empty')
        elif link != self.link:
            p = Podcast()
            p._get_data(link)
            if p.error:
                errors.append('Source URL: ' + p.error[1])
            else:
                fields.append('link = ?')
                values.append(link)

        if not errors:
            if fields:
                sql = """
                    UPDATE podcasts SET %s WHERE id = ?
                """
                sql %= ', '.join(fields)
                db.cnx.cursor().execute(sql, tuple(values) + (self.id,))
                db.cnx.commit()
            return True

        return errors

    def delete(self):
        cursor = db.cnx.cursor()

        # delete episodes
        sql = """
            DELETE FROM episodes WHERE podcast_id = ?
        """
        cursor.execute(sql, (self.id,))
        # delete feed
        sql = """
            DELETE FROM podcasts WHERE id = ?
        """
        cursor.execute(sql, (self.id,))
        db.cnx.commit()

        # remove media dir
        if os.path.exists(self.full_path):
            for root, dirs, files in os.walk(self.full_path, topdown=False):
                for name in files:
                    os.remove(os.path.join(root, name))
            os.rmdir(self.full_path)


class PodcastEpisode(object):
    def __init__(self, podcast = None, entry = None):
        self.id          = None
        self.podcast_id  = None
        self.guid        = None
        self.link        = None
        self.length      = None
        self.title       = None
        self.description = None
        self.duration    = None
        self.updated     = None
        self.downloaded  = 0
        self.played      = 0
        self.locked      = 0
        self.path        = None
        self.last_pos    = None

        self.error = None

        if not podcast or not entry:
            return

        data = self._get_data(entry, podcast.path)
        if data:
            self._load_from_dict(data)

    @classmethod
    def load(cls, id = None, guid = None, data = None):
        if id:
            sql = """
                SELECT * FROM episodes WHERE id = ?
            """
            data = db.cnx.cursor().execute(sql, (id,)).fetchone()
        elif guid:
            sql = """
                SELECT * FROM episodes WHERE guid = ?
            """
            data = db.cnx.cursor().execute(sql, (guid,)).fetchone()
        if data:
            e = PodcastEpisode()
            e._load_from_dict(data)
            return e
        else:
            return None

    def _load_from_dict(self, d):
        for key in d:
            if hasattr(self, key):
                setattr(self, key, d[key])

    def _get_data(self, entry, path):
        self.d = entry

        data = {}
        # guid
        data['guid'] = entry.get('id', None)
        # link
        link = None
        length = None
        if entry.get('enclosures') and len(entry.enclosures) > 0:
            for enclosure in entry.enclosures:
                if enclosure.get('href') and util.normalize_feed_url(enclosure.href) is not None:
                    link = util.normalize_feed_url(enclosure.href)
                    length = enclosure.get('length')
                    break
        elif entry.get('id'):
            link = util.normalize_feed_url(entry.id)
        if link:
            (filename, extension) = util.filename_from_url(link)
            file_type = util.file_type_by_extension(extension)
            if file_type is None:
                link = None
        if link is None:
            self.error = (1, 'entry has no downloadable enclosure')
            return False
        else:
            data['link'] = link
            data['length'] = length
        # title
        data['title'] = entry.get('title', util.get_first_line(util.remove_html_tags(entry.get('summary', ''))))
        # description
        if entry.get('summary'):
            data['description'] = util.remove_html_tags(entry.get('summary'))
        else:
            data['description'] = entry.get('title', entry.get('subtitle', ''))
        # duration
        if entry.get('dureereference') and entry.dureereference:
            data['duration'] = entry['dureereference']
        elif entry.get('itunes_duration') and entry.itunes_duration:
            data['duration'] = entry['itunes_duration']
        if 'duration' in data and str(data['duration']).isdigit():
            data['duration'] = time.strftime("%H:%M:%S", time.gmtime(int(data['duration'])))
        # updated
        if entry.get('updated_parsed') and entry.updated_parsed:
            data['updated'] = rfc822.mktime_tz(entry.updated_parsed + (0,))
        else:
            data['updated'] = time.time()

        # path
        filename = ''.join(util.filename_from_url(data['link']))
        data['path'] = os.path.join(path, filename)

        return data

    @property
    def full_path(self):
        media_dir = db.get_setting('media_dir')
        if media_dir:
            return os.path.join(media_dir, self.path).encode('utf-8')
        else:
            return None

    def delete(self):
        if self.downloaded == 1:
            if os.path.exists(self.full_path):
                os.remove(self.full_path)

            sql = """
                UPDATE episodes SET downloaded = -1 WHERE id = ?
            """
            self.downloaded = -1
        else:
            sql = """
                DELETE FROM episodes WHERE id = ?
            """
        db.cnx.cursor().execute(sql, (self.id,))
        db.cnx.commit()

    def download(self, check_only = False):
        from ecore import Exe

        result = fetch_url(self.link, info_only = True)
        if result['status'] >= 200 and result['status'] < 400:
            self.length = int(result['info']['content-length'])
            if not check_only:
                cmd = [
                    '/usr/bin/wget',
                    '--quiet',
                    #'--timeout=15', # not available in BusyBox wget
                    #'--continue',
                    '--output-document',
                    re.escape(self.full_path.decode('utf-8')),
                    '"%s"' % self.link
                    ]
                return Exe(' '.join(cmd).encode('utf-8'))
            else:
                return True
        else:
            return False

    def set_downloaded(self):
        sql = """
            UPDATE episodes SET downloaded = 1 WHERE id = ?
        """
        db.cnx.cursor().execute(sql, (self.id,))
        db.cnx.commit()
        self.downloaded = 1

    def set_undownloaded(self):
        if os.path.exists(self.full_path):
            os.remove(self.full_path)

        if self.downloaded == 1:
            sql = """
                UPDATE episodes SET downloaded = 0 WHERE id = ?
            """
            db.cnx.cursor().execute(sql, (self.id,))
            db.cnx.commit()
            self.downloaded = 0

    def save(self, podcast_id):
        cursor = db.cnx.cursor()

        values = (
            podcast_id,
            self.guid,
            self.link,
            self.title,
            self.description,
            self.duration,
            self.length,
            self.updated,
            self.path,
            )

        sql = """
            INSERT INTO episodes (podcast_id, guid, link, title, description, duration, length, updated, path)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
        """
        cursor.execute(sql, values)
        db.cnx.commit()

        self.id = cursor.lastrowid

    def toggle_ignore_status(self):
        self.downloaded = -1 if self.downloaded == 0 else 0
        sql = """
            UPDATE episodes SET downloaded = ? WHERE id = ?
        """
        db.cnx.cursor().execute(sql, (self.downloaded, self.id,))
        db.cnx.commit()

    def toggle_locked_status(self):
        self.locked = not self.locked
        sql = """
            UPDATE episodes SET locked = ? WHERE id = ?
        """
        db.cnx.cursor().execute(sql, (self.locked, self.id,))
        db.cnx.commit()

    def toggle_played_status(self):
        self.update_played_status(not self.played)

    def update_duration(self, duration):
        duration = time.strftime("%H:%M:%S", time.gmtime(duration))
        if duration == self.duration:
            return
        sql = """
            UPDATE episodes SET duration = ? WHERE id = ?
        """
        db.cnx.cursor().execute(sql, (duration, self.id,))
        db.cnx.commit()
        self.duration = duration

    def update_last_pos(self, pos):
        sql = """
            UPDATE episodes SET last_pos = ? WHERE id = ?
        """
        if pos >= 5:
            pos = pos - 5
        db.cnx.cursor().execute(sql, (pos, self.id,))
        db.cnx.commit()
        self.last_pos = pos

    def update_played_status(self, played):
        sql = """
            UPDATE episodes SET played = ? WHERE id = ?
        """
        db.cnx.cursor().execute(sql, (played, self.id,))
        db.cnx.commit()
        self.played = played
